Pro ASP.NET Core MVC2(第7版)翻译

第8章:运动商店:一个实际的应用程序

作者:Adam Freeman 翻译:陈广 日期:2018-8-31


在前几章中,我构建了快速而简单的 MVC 应用程序。我描述了 MVC 模式、必备的 C# 特性以及优秀的 MVC 开发人员所需的各种工具。现在是时候把所有东西整合在一起,并创建一个简单且现实的电子商务应用程序了。

我的应用程序叫 SportsStore(运动商店),它将遵循所有在线商店采用的经典方法。我将创建一个在线产品目录让客户浏览类别和页面,一个购物车让用户添加和删除产品,一个结算系统让客户可以输入他们的快递信息。我还将创建一个管理区域,其中包括用于管理目录的创建、读取、更新和删除(CRUD)工具,并保护它以便只有登录管理员才能进行更改。

本章和之后章节的目标是通过创建一个尽可能现实的例子,让您了解真正的 MVC 开发是什么样的。我希望聚焦于 ASP.NET Core MVC,所以简化了与外部系统(如数据库)的集成,并完全省略了其他系统(如支付处理)。

您可能会发现,我构建所需的基础结构进展较为缓慢,但是对 MVC 应用程序的初期投入会带来好处,它利于产生可维护、可扩展、结构良好的代码,并能很好地支持单元测试。


单元测试

关于 MVC 中单元测试的易用性,以及单元测试如何成为开发过程中一个重要和有用的部分,我做了相当充分的讨论。您将在这本书的这一部分中看到这一点,我已经包含了单元测试和技术的细节,因为它们涉及到关键的 MVC 特性。

我知道这不是一个普遍的观点。如果你不想单元测试,这对我来说没问题。为此,当我对于测试有话要说的时候,会把它放在像这样的边栏里。如果您对单元测试没有兴趣,可以跳过这个部分,SportsStore 应用程序也会运行得很好。您不需要进行任何类型的单元测试来获得 ASP.NET Core MVC 的技术优势,当然,对测试的支持是采用 ASP.NET Core MVC 的关键原因。


我在 SportsStore 应用程序中使用大多数 MVC 功能在本书后面都会有它们自己的章节。为避免在这里重复所有内容,我会告诉您仅对示例应用程序有意义的部分,并指出其他章节以获得深入信息。

我将调用构建应用程序所需的每个步骤,这样您就能看到 MVC 特性是如何组合在一起的。当我创建视图的时候,您应该特别注意。如果不仔细跟随这些例子,将会得到一些奇怪的结果。

入门

阅读本章时,如果需要在自己的计算机上编写 SportsStore 应用程序,则需要安装 Visual Studio,并确保安装了 LocalDB 选项。如果您按照第2章中的说明,将自动安装 LocalDB 选项,该选项是持久存储数据所必需的。

注意:如果您只想跟踪这个项目而不必重新创建它,那么您可以从 GitHub 存储库下载这本书的 SportsStore 项目, https://github.com/apress/pro-asp.net-core-mvc-2。当然,您不需要跟着走。我试着让屏幕截图和代码列表尽可能容易理解,以防你在火车上、咖啡店或类似的地方读这本书。

创建 MVC 项目

我将遵循我在前面几章中使用的相同的基本方法,即从一个空项目开始,并添加我所需的所有配置文件和组件。在 Visual Studio 菜单中选择【新建】➤【项目】,并选择【ASP.NET Core Web 应用程序】项目模板,如图8-1所示。我将项目名称设置为 SportsStore 并单击【确定】按钮。

图8-1 选择项目类型

我选择了【空】模板,如图8-2所示。请确保在对话窗口顶部菜单中选择了【.NET Core】和【ASP.NET Core 2.1】,并且没有勾选【启用 Docker 支持】,然后单击【确定】按钮创建 SportsStore 项目。

图8-2 选择项目模板

创建文件夹结构

下一步是添加包含 MVC 应用程序所需的应用程序组件的文件夹:模型、控制器和视图。对于表8-1所述的每个文件夹,在【解决方案资源管理器】中右键单击 SportsStore 项目,在弹出菜单中选择【添加】➤【新建文件夹】,设置文件夹名称。稍后还需要额外的文件夹,但是这些文件夹反映了 MVC 应用程序的主要部分,已经足够开始了。

表 8-1:SportsStore 项目所需文件夹

名称 描述
Models 此文件夹将包含模型类
Controllers 此文件夹将包含控制器类
Views 该文件夹保存与视图相关的所有内容,包括单独的 Razor 文件、view start 文件和 view imports 文件。

配置应用程序

Startup类负责 ASP.NET Core 应用程序的配置,清单8-1显示了我对Startup类所做的更改,以启用 MVC 框架和一些对开发有用的相关特性。

注意Startup类是一个重要的 ASP.NET Core 特性。我将在14章进行详细描述

清单 8-1:SportsStore 文件夹下的 Startup.cs 文件,启动功能

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace SportsStore
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => { });
        }
    }
}

ConfigureServices方法用于设置可通过依赖项注入特性在整个应用程序中使用的共享对象,我将在第18章进行描述。在ConfigureServices方法中调用的AddMvc方法是一个扩展方法,用于设置 MVC 应用程序中使用的共享对象。

Configure方法用于设置接收和处理 HTTP 请求的功能。我在Configure方法中调用的每个方法都是设置 HTTP 请求处理器的扩展方法,如表8-2所述。

表 8-2:Startup 类中调用的初始方法

方法 描述
UseDeveloperExceptionPage() 此扩展方法显示应用程序中发生的异常的详细信息,在开发过程中非常有用。在部署的应用程序中不应该启用它,我在第12章中部署应用程序时禁用了这个特性。
UseStatusCodePages() 这个扩展方法向 HTTP 响应添加了一个简单的消息,否则不会有一个 body,比如404-NotFindResponse。
UseStaticFiles() 此扩展方法支持从 wwwroot 文件夹提供静态内容。
UseMvc() 此扩展方法启动 ASP.NET Core MVC

接下来,我需要准备 Razor 视图应用程序。右键单击 Views 文件夹,从弹出菜单中选择【添加】➤【新建项】,并从【ASP.NET】类别中选择【Razor 视图导入】模板,如图8-3所示。

图8-3 创建视图导入文件

单击【添加】按钮创建 _ViewImports.cshtml 文件,并输入清单8-2所示的代码。

清单 8-2:Views 文件夹下的 _ViewImports.cshtml 文件的内容

@using SportsStore.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@using语句将允许我在视图中使用SportsStore.Models命名空间中的类型,而不需要引用命名空间。@addTagHelper语句启用内置标签助手,稍后我将使用它创建反映运动商店应用程序配置的 HTML 元素。

创建单元测试项目

创建单元测试项目需要的与第7章描述的过程相同。在【解决方案资源管理器】中右键单击【解决方案"SportsStore"】项,在弹出菜单中选择【添加】➤【新建项目】。选择【xUnit 测试项目(.NET Core)】模板,如图8-4所示,并设置项目名称为 SportsStore.Tests。单击【确定】按钮创建单元测试项目。

图8-4 创建单元测试项目

单元测试项目创建完毕后,在【解决方案资源管理器】中右键单击【SportsStore.Tests】项目,在弹出菜单中选择【编辑 Edit SportsStore.Tests.csproj】。添加清单8-3中所示的新元素,将 Moq 包添加到测试项目中,并创建对主 SportsStore 项目的引用。请确保为 Moq 包指定了清单中所示的版本。

清单 8-3:SportsStore.Tests 文件夹下的 SportsStore.Tests.cspro 文件,添加程序包

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\SportsStore\SportsStore.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.8.0" />
    <PackageReference Include="xunit" Version="2.3.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
    <PackageReference Include="Moq" Version="4.9.0" />
  </ItemGroup>

</Project>

当您保存 csproj 文件,Visual Studio 将下载 Moq 包并将其安装到单元测试项目中,并创建一个对主 SportsStore 项目的引用,以便它包含的类可以用于测试。

检查和运行应用程序

应用程序和单元测试项目已创建和配置完毕,并为开发做好准备。解决方案资源管理器应该包含如图8-5所示的项。如果您看到不同的项或项不在相同的位置,则会出现问题,所以请花时间检查所有的东西是否都在正确的位置。

图8-5 用于 SportsStore 应用程序和单元测试项目的解决方案资源管理器

如果你在【调试】菜单中选择【开始调试】(如果您喜欢我在第6章中描述的迭代开发风格,则可以选择【开始执行(不调试)】),您将看到一个如图8-6所示错误页。显示错误消息是因为应用程序中目前没有控制器来处理请求,这是我稍后将讨论的内容。

译者注:此时的应用程序根本无法在新版 Visual Studio 中运行,因为程序中存在错误,_ViewImports.cshtml 文件中的SportsStore.Models命名空间还未存在。等清单8-4中的代码加入后程序就可以运行了。

图8-6 运行 SportsStore 应用程序

从域模型开始

所有的项目都从域模型开始,这是 MV C应用程序的核心。由于这是一个电子商务应用程序,最需要的是一个产品模型。我在 Models 文件夹中添加了一个名为 Product.cs 的类文件,并使用它来定义清单8-4所示的类。

清单 8-4:Models 文件夹下的 Product.cs 文件的内容

namespace SportsStore.Models
{
    public class Product
    {
        public int ProductID { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public string Category { get; set; }
    }
}

创建存储库

我需要某种方法从数据库中获取Product对象。正如我在第3章中所解释的,模型包括用于从持久数据存储中存储和检索数据的逻辑。目前我并不担心如何实现数据持久性,但我将开始为其定义接口。在 Models 文件夹下添加一个名为 IProductRepository.cs 的 C# 接口,并使用它来定义清单8-5所示的接口。

清单 8-5:Models 文件夹下的 IProductRepository.cs 文件的内容

using System.Linq;

namespace SportsStore.Models
{
    public interface IProductRepository
    {
        IQueryable<Product> Products { get; }
    }
}

这个接口使用IQueryable<T>来允许调用者获得一系列Product对象。IQueryable<T>接口是从大家更熟悉的IEnumerable<T>接口派生出来的,它表示可以查询的对象集合,例如由数据库管理的对象。

依赖于IProductRepository接口的类可以获得Product对象,而不需要知道它们是如何存储的,也不需要知道实现类将如何传递它们。


理解 IEnumerable<T>IQueryable<T> 接口

IQueryable<T>接口非常有用,因为它允许有效地查询对象集合。在本章稍后,我添加了对从数据库检索Product对象子集的支持,使用IQueryable<T>接口允许我只使用标准 Linq 语句向数据库请求所需的对象,而无需知道是哪种数据库服务器存储数据,也不需要知道它如何处理查询的。如果没有IQueryable<T>接口,我必须从数据库中检索所有Product对象,然后丢弃我不想要的对象,这将成为一个昂贵的操作,因为应用程序使用的数据量会增加。因此数据库存储接口和类中通常使用的接口是IQueryable<T>,而不是IEnumerable<T>

尽管如此,必须注意IQueryable<T>接口,因为每次枚举对象集合时,将再次计算查询,这意味着将向数据库发送新的查询。这可能会削弱使用IQueryable<T>的效率。在这种情况下,可以使用ToListToArray扩展方法将IQueryable<T>转换为更可预测的形式。


创建伪存储库

现在我已经定义了一个接口,可以实现持久化机制并将其连接到数据库,但我想首先添加应用程序的其他部分。为此,我将创建IProductRepository接口的伪实现,直到我回到数据存储的主题。要创建伪存储库,我在 Models 文件夹下添加了一个名为 FakeProductRepository.cs 的类文件,并使用它定义如清单8-6所示的类。

清单 8-6:Models 文件夹下的 FakeProductRepository.cs 文件的内容

using System.Collections.Generic;
using System.Linq;
namespace SportsStore.Models
{
    public class FakeProductRepository : IProductRepository
    {
        public IQueryable<Product> Products => new List<Product> {
            new Product { Name = "Football", Price = 25 },
            new Product { Name = "Surf board", Price = 179 },
            new Product { Name = "Running shoes", Price = 95 }
        }.AsQueryable<Product>();
    }
}

FakeProductRepository类通过返回一个固定的Product对象集合作为Products属性的值来实现IProductRepository接口。AsQueryable方法用于将固定的对象集合转换为IQueryable<Product>,这是实现IProductRepository接口所必需的,并且允许我创建一个兼容的假存储库,而不必处理实际的查询。

注册存储库服务

MVC 强调使用松耦合组件,这意味着您可以在应用程序的一个部分中进行更改,而不必在其他地方进行相应的更改。这种方法将应用程序的某些部分归类为服务,这些服务提供了应用程序的其他部分使用的特性。然后,可以更改或替换提供服务的类,而不需要更改使用它的类。我将在第18章中对此进行深入的解释,但是对于 SportsStore 应用程序,我希望创建一个存储库服务,它允许控制器获取实现IProductRepository接口的对象,而无需知道使用的是哪个类。这将允许我开始使用在上一节中创建的简单的FakeProductRepository类来开发应用程序,之后再将其替换为一个真正的存储库,而不必对所有需要访问存储库的类进行更改。服务在Startup类的ConfigureServices方法中注册,在清单8-7中,我为存储库定义了一个新服务。

清单 8-7:SportsStore 文件夹下的 Startup.cs 文件,创建存储库服务

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;

namespace SportsStore
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<IProductRepository, FakeProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => { });
        }
    }
}

我添加到ConfigureServices方法中的语句告诉 ASP.NET Core,当一个组件(例如控制器)需要IProductRepository接口的实现时,它将接收FakeProductRepository类的一个实例。AddTransient方法指明了当每次需要IProductRepository接口时,都会创建一个新的FakeProductRepository对象。不用担心现在无法理解,您将很快看到它是如何融入到应用程序的,我会在第18章详细解释它是如何发生的。

显示产品列表

在本章的剩余部分,我本可以构建域模型和存储库,完全不触及应用程序的其余部分。不过,我想您会觉得这很无聊,所以我切换轨道,开始认真地使用 MVC,然后在需要的时候回来添加模型和存储库功能。

在本节中,我将创建一个控制器和一个 action 方法,用于显示存储库中产品的细节。目前,只针对假存储库中的数据,但我稍后会解决的。我还将建立一个初始的路由配置,以便 MVC 知道如何将应用程序的请求映射到我创建的控制器中。


使用 Visual Studio MVC 脚手架

在这本书中,我通过右键单击【解决方案资源管理器】中的一个文件夹,从弹出菜单中选择【添加】➤【新建项】,然后从【添加新项】窗口中选择一个模板来创建 MVC 控制器和视图。还有一种称为脚手架的替代方法,即 Visual Studio 在【添加】菜单中提供的专门用于创建控制器和视图的项。当您选择这些菜单项时,系统会提示您选择要创建的组件的方案,例如具有读/写 actions 的控制器,或包含用于创建指定模型对象的表单的视图。

我在本书中并未使用脚手架。脚手架生成的代码和标记非常通用,几乎毫无用处,而所支持的场景集很窄,不能解决常见的开发问题。本书目标不仅是确保您知道如何创建 MVC 应用程序,而且还要解释在幕后如何工作,当将创建组件的责任交给脚手架时,更难做到这一点。

尽管如此,如果您的开发风格可能与我的不同,您可能会发现您更喜欢在自己的项目中使用脚手架。这是完全合理的,然而我建议您花时间了解脚手架的功能,这样就能知道如果没有得到预期的结果,应该在哪里查看。


添加控制器

在应用程序中添加第一个控制器,我在 Controllers 文件夹下添加了一个名为 ProductController.cs 的类文件,并定义如清单8-8所示的类。

清单 8-8:Controllers 文件夹下的 ProductController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }
    }
}

当 MVC 需要创建ProductController类的新实例来处理 HTTP 请求时,它将检查构造函数,并查看是否需要实现IProductRepository接口的对象。为了确定应该使用哪个实现类,MVC 在Startup类中参考配置,获知它应该使用FakeRepository,并且每次都应该创建一个新实例。MVC 创建一个新的FakeRepository对象,并使用它调用ProductController构造函数,以便创建将处理 HTTP 请求的控制器对象。

这称为依赖注入,这种方法允许ProductController构造函数通过IProductRepository接口访问应用程序的存储库,而不需要知道配置了哪个实现类。稍后,我将用真正存储库替换伪存储库,依赖注入意味着控制器将继续工作而无需更改。

注意:一些开发人员不喜欢依赖注入,认为它使应用程序更加复杂。这不是我的观点,但如果你对依赖注入还不熟悉,那么我建议你等到读完第18章后再下决心。

接下来,我添加了一个名为List的 action 方法,它将渲染一个视图,显示存储库中产品的完整列表,如清单8-9所示。

清单 8-9:Controllers 文件夹下的 ProductController.cs 文件,添加 action 方法

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List() => View(repository.Products);
    }
}

象这样调用View方法(不指定视图名称)是告诉 MVC 渲染 action 方法的默认视图。将Product对象集合从存储库传递到View方法,为框架提供了在强类型视图中填充模型对象的数据。

添加和配置视图

我需要创建一个视图来将内容呈现给用户,但是需要一些准备步骤来简化视图的编写。首先是创建一个共享布局,该布局定义将包含在发送给客户端的所有 HTML 响应中的公共内容。共享布局是确保视图一致并包含重要的 JavaScript 文件和 CSS 样式表的有用方法,我在第5章中解释了它们的工作原理。

我创建了 Views/Shared 文件夹,并在其中添加了一个名为 _Layout.cshtml 的新的【Razor 布局】,这是 Visual Studio 为该项目类型分配的默认名称。清单8-10显示了 _Layout.cshtml 文件,我对默认内容做了一些更改,即将title元素的内容设置为 SportsStore。

清单 8-10:Views/Shared 文件夹下的 _Layout.cshtml 文件的内容

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>SportsStore</title>
</head>
<body>
    <div>
        @RenderBody()
    </div>
</body>
</html>

接下来我需要配置应用程序以便默认使用 _layout.cshtml 文件。这是通过在 Views 文件夹中添加一个名为 _ViewStart.cshtml 的【Razor 视图开始】页面文件来完成的。Visual Studio 添加的默认内容,如清单8-11所示。它选择了一个名为 _layout.cshtml 的布局,对应于清单8-10中所示的文件。

清单 8-11:Views 文件夹下的 _ViewStart.cshtml 文件的内容

@{
    Layout = "_Layout";
}

现在,我需要添加当List action 方法用于处理请求时将显示的视图。我创建了 Views/Product 文件夹,并向其添加了一个名为 List.cshtml 的 Razor 视图文件。然后,我添加了清单8-12所示的标记。

清单 8-12:Views/Product 文件夹下的 List.cshtml 文件的内容

@model IEnumerable<Product>

@foreach (var p in Model)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

文件顶部的@model表达式指定视图将从 action 方法接收一系列Product对象作为其模型数据。我使用@foreach表达式对序列进行处理,并为接收到的每个Product对象生成一组简单的 HTML 元素。

视图不知道Product对象来自何处,它们是如何获得的,或者它们是否代表应用程序已知的所有产品。相反,视图只处理如何使用 HTML 元素显示每个产品的细节,这与我在第3章中描述的关注点分离是一致的。

提示:我使用ToString("c")方法将Price属性转换为字符串,该方法根据服务器上实际的区域性设置将数字值渲染为货币。例如,如果服务器设置为 en-US ,则(1002.3).ToString("c")将返回$1,002.30,但如果服务器设置为 en-GB,则相同的方法将返回£1,002.30

译者注:我们的中文操作系统会显示为¥1,002.30,所以使用这个方法是不靠谱的,货币前缀还是老老实实自己加的好。

设置默认路由

我需要告诉 MVC,到达应用程序的根 URL 的请求(http://mysite/)应发送到ProductController类中的 List action 方法。我通过编辑Startup类中的语句来实现这一点,Startup类设置了处理 HTTP 请求的 MVC 类,如清单8-13所示。

清单 8-13:SportsStore 文件夹下的 Startup.cs 文件,更改默认路由

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;

namespace SportsStore
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<IProductRepository, FakeProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
                routes.MapRoute(
                name: "default",
                template: "{controller=Product}/{action=List}/{id?}");
            });
        }
    }
}

Startup类的Configure方法用于设置请求管道,它由类(称为中间件)组成,这些类将检查 HTTP 请求并生成响应。UseMvc方法设置 MVC 中间件,其中一个配置选项是将 URL 映射到控制器和 aciton 方法的方案。我在第15章和第16章中详细描述了路由系统,但是清单8-13中的更改告诉 MVC 向Product控制器的List action 方法发送请求,除非请求 URL 另有指定。

提示:注意,我已经将清单8-13中的控制器的名称设置为Product,而不是类名ProductController,这是 MVC 命名约定的一部分,其中控制器类名通常以 Controller 结尾,但在引用类时忽略了这部分名称。我将在第31章中解释命名约定及其效果。

译者注:之前几章一直使用的是 Home 控制器,它是默认路由的主控制器,此例通过更改路由配置将主控制器配置为 List,从而演示了如何更改主控制器名称

运行应用程序

所有基本要素都已就位。我有一个带 action 方法的控制器,当请求应用程序的默认 URL 时,MVC 将使用此 action 方法。MVC 还将创建FakeRepository类的实例,并使用它创建一个新的控制器对象来处理请求。伪存储库将为控制器提供一些简单的测试数据,并通过 action 方法传递给 Razor 视图,这样发送到浏览器的 HTML 响应将包含每个Product对象的详细信息。在生成 HTML 响应时,MVC 将结合 action 方法选择的视图中的数据与共享布局中的内容,生成一个完整的浏览器可以解析和显示 HTML 文档。您可以通过启动应用程序看到结果,如图8-7所示。

这是 ASP.NET Core MVC 的典型开发模式。首先需要投入时间来设置所有内容,然后应用程序的基本功能迅速结合起来。

图8-7 查看应用程序基本功能

准备数据库

我可以显示一个包含产品细节的简单视图,但它使用的是伪存储库所包含的测试数据。在用真实数据实现真正的存储库之前,我需要建立一个数据库并填充一些数据。

我将使用 SQL Serve 作为数据库,我将使用 Entity Framework Core(EF Core)访问数据库,它是Microsoft .NET 对象-关系映射(ORM)框架。ORM 框架通过普通的 C# 对象显示关系数据库的表、列和行。

注意:这是一个可以从广泛的工具和技术中选择的领域。不仅有不同的关系数据库可用,还可以使用对象存储库、文档存储和一些机密的替代方案。还有其他的 .NET ORM 框架,每个框架采用的方法略有不同;这些变化可能更适用于您的项目。

我使用 Entity Framework Core 有如下几个原因:它工作起来很简单,与 LINQ 的集成是一流的(我喜欢使用 LINQ),而且它与 ASP.NET Core MVC 一起工作得很好。早期的版本有些不合时宜,但目前的版本优雅且功能丰富。

SQL Server 的一个很好的特性是 LocalDB,它是专门为开发人员设计的基本SQL Server功能免管理实现。使用此特性,我可以在构建项目时跳过设置数据库的过程,然后部署到完整的 SQL Server 实例中。大多数 MVC 应用程序都会部署到由专业管理员运行的托管环境中,因此 LocalDB 特性意味着数据库配置可以留给 DBA,开发人员可以继续编写代码。

提示:如果在安装 Visual Studio 时没有选择 LocalDB,那么现在就需要通过 Visual Studio 安装程序的各个组件部分进行选择。如果您按照第2章中的说明,那么 LocalDB 功能应该安装完成并可以使用。

安装 Entity Framework Core 工具包

默认情况下,当 Visual Studio 创建项目时,Entity Framework Core 的主要功能将添加到项目中。只是一个命令行工具需要额外的 NuGet 包来提供,它用于创建筹备数据库以存储应用程序数据的类,称为迁移(migrations)

要将包添加到项目中,在【解决方案资源管理器】中右键单击 SportsStore 项,在弹出菜单中选择【编辑 SportsStore.csproj】,并按照清单8-14所示对文件进行更改。注意使用清单中指定的版本,并注意包是使用DotNetCliToolReference元素添加的,而不是为现有包使用的PackageReference元素。

注意:您必须通过编辑文件来安装此包。不能使用 Nuget 包管理器或 DotNet 命令行工具添加此类型的包。

译者注:Visual Studio 15.8 版已经内置 Microsoft.EntityFrameworkCore.Tools.DotNet,所以不要添加清单 8-14 的代码,否则会出现警告信息。

清单 8-14:SportsStore 文件夹下的 SportsStore.csproj 文件,添加程序包

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet"
        Version="2.0.3" />
  </ItemGroup>

</Project>

当您保存文件,Visual Studio 将下载并安装 Entity Framework Core 命令行工具并将它们加进项目。

创建数据库类

数据库上下文类是应用程序和 Entity Framework Core 之间的桥梁,并使用模型对象提供对应用程序数据的访问。为创建 SportsStore 应用程序数据库上下文类,我在 Models 文件夹中添加了一个名为 ApplicationDbContext.cs 的类文件,并定义了清单8-15所示的类。

清单 8-15:Models 文件夹下的 ApplicationDbContext.cs 文件的内容

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;

namespace SportsStore.Models
{
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }

        public DbSet<Product> Products { get; set; }
    }
}

DbContext基类提供对 Entity Framework Core 的底层功能的访问,Products属性将提供对数据库中Product对象的访问。ApplicationDbContext类是从DbContext派生的,并添加了将用于读取和写入应用程序数据的属性。目前只有一个属性,它将提供对Products对象的访问。

创建存储库类

建立数据库所需的大部分工作已经完成,虽然看起来并非如此。下一步是创建一个实现IProductRepository接口的类,并使用 Entity Framework Core 获取其数据。我在 Models 文件夹中添加了一个名为 EFProductRepository.cs 的类文件,并使用它来定义存储库类,如清单8-16所示。

清单 8-16:Models 文件夹下的 EFProductRepository.cs 文件的内容

using System.Collections.Generic;
using System.Linq;

namespace SportsStore.Models
{
    public class EFProductRepository : IProductRepository
    {
        private ApplicationDbContext context;
        public EFProductRepository(ApplicationDbContext ctx)
        {
            context = ctx;
        }
        public IQueryable<Product> Products => context.Products;
    }
}

我将在向应用程序中添加功能时再向此文件添加相应代码,但目前,存储库实现只是将IProductRepository接口定义的Products属性映射到由ApplicationDbContext类定义的Products属性上。上下文类中的Products属性返回一个DbSet<Product>对象,该对象实现IQueryable<T>接口,并且在使用 Entity Framework Core 时很容易实现IProductRepository接口。这将确保对数据库的查询只检索所需的对象,如本章前面所述。

定义连接字符串

连接字符串指定数据库的位置和名称,并提供应用程序如何连接到数据库服务器的配置设置。连接字符串存储在名为 appsettings.json 的 JSON 文件中,我在 SportsStore 项目中使用【添加新项】窗口的【ASP.NET】类别中的【应用设置文件】模板创建了该文件。

Visual Studio 在创建文件时将占位符连接字符串添加到 appSetings.json 文件,我在清单8-17中替换了该文件。

提示:连接字符串必须表示为一个完整的行,这在 Visual Studio 编辑器中没有问题,但不适合打印页面,这解释了清单8-17中的尴尬格式。在自己的项目中定义连接字符串时,请确保连接字符串项的值位于单行上。

清单 8-17:SportsStore 文件夹下的 appsettings.json 文件,编辑连接字符串

{
  "Data": {
    "SportStoreProducts": {
      "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_Connection=True;MultipleActiveResultSets=true"
    }
  }
}

在配置文件的Data部分,我已经将连接字符串的名称设置为SportStoreProductsConnectionString项的值指定 LocalDB 特性应该用于一个名为 SportsStore 的数据库。

配置应用程序

下一步是读取连接字符串,并配置应用程序以使用它连接到数据库。清单8-18显示了Startup类所需的的更改,它接收 appsettings.json 文件中包含的配置数据的详细信息,并使用它来配置 Entity Framework Core 。(读取JSON文件的工作由Program类处理,我在第14章中描述)。

清单 8-18:SportsStore 文件夹下的 Startup.cs 文件,配置应用程序

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) =>
            Configuration = configuration;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration["Data:SportStoreProducts:ConnectionString"]));
            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
                routes.MapRoute(
                name: "default",
                template: "{controller=Product}/{action=List}/{id?}");
            });
        }
    }
}

我添加到Startup类中的构造函数接收从 appsettings.json 文件加载的配置数据,该配置数据是通过一个实现IConfiguration接口的对象呈现的。构造函数将IConfiguration对象分配给一个名为Configuration的属性,以便它可以被Startup类的其余部分使用。

第14章中,我解释了如何读取和访问配置数据。对于 SportsStore 应用程序,我添加了一系列方法调用,这些调用设置了ConfigureServices方法中的 Entity Framework Core。

...
services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(Configuration["Data:SportStoreProducts:ConnectionString"]));
...

AddDbContext扩展方法设置了 Entity Framework Core 为清单8-15中创建的数据库上下文类提供的服务。正如我在第14章中解释的那样,在Startup类中使用的许多方法都可以通过 options 参数来配置服务和中间件特性。AddDbContext方法的参数是一个 lambda 表达式,它接收为上下文类配置数据库的 options 对象。在本例中,我使用UseSqlServer方法配置了数据库,并从Configuration属性获得指定连接字符串。

我在启动类中所做的另一个更改是将伪存储库替换为真正的存储库,如下所示:

...
services.AddTransient<IProductRepository, EFProductRepository>();
...

应用程序中使用IProductRepository接口的组件(目前只是Product控制器)在创建时将接收EFProductRepository对象,这将使他们能够访问数据库中的数据。我在第18章中详细解释了这一点,但其结果是伪数据将被数据库中的真实数据无缝地替换,而不必更改ProductController类。

禁用范围验证

使用 Entity Framework Core 需要对我在第18章中描述的依赖项注入特性进行配置更改。在将控制权交给 Startup 类之前,Program类负责启动和配置ASP.NET Core,清单8-19显示了所需的更改。如果不进行此更改,则在下一节中尝试创建数据库架构时将引发异常。

清单 8-19:SportsStore 文件夹下的 Program.cs 文件,准备 Entity Framework Core

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace SportsStore
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseDefaultServiceProvider(options =>
                    options.ValidateScopes = false);
    }
}

译者注:由于我使用的是 Visual Studio 15.8 版,新版对 Program.cs 的代码做了些许发动以,所以这里的代码跟原文稍有不同。

我在第14章中详细解释了 ASP.NET Core 是如何配置的,但这是对 SportsStore 应用程序所需的Program类的唯一更改。

创建数据库迁移

Entity Framework Core 能够通过名为迁移的功能来使用模型类为数据库生成架构。准备迁移时,EF Core 将创建一个 C# 类,其中包含准备数据库所需的 SQL 命令。如果需要修改模型类,则可以创建包含反映更改所需的 SQL 命令的新迁移。通过这种方式,您不必担心手动编写和测试 SQL 命令,只需关注应用程序中的 C# 模型类即可。

Entity Framework Core 命令是从命令行执行的。打开一个新的命令提示符或 PowerShell 窗口,导航到 SportsStore 项目文件夹(其中包含 Startup.cs 和 appsettings.json 文件),然后运行以下命令来创建迁移类,以便为数据库的第一次使用做准备:

dotnet ef migrations add Initial

当命令执行完毕,您将会在【解决方案资源管理器】窗口看到 Migrations 文件夹。这是 Entity Framework Core 存储迁移类的地方。其中一个文件名是时间戳后面跟着_initial.cs,这个类将用于创建数据库的初始架构。如果检查此文件的内容,可以看到如何使用Product模型类创建架构。


关于 Add-Migration 和 Update-Database 命令

如果您是一个经验丰富的 Entity Framework 开发人员,您可能习惯于使用 Add-Migration 命令来创建数据库迁移,并使用 Update-Database 命令将其应用于数据库。

随着 .NET Core 的引入,Entity Framework Core 增加了集成到 dotnet 命令行工具中的命令,如清单8-14那样,将 Microsoft.EntityFrameworkCore.Tools.DotNet 包添加到项目中。这些命令是我在本章中使用的,因为它们与其他 .net 命令一致,可以在任何命令提示符或 PowerShell 窗口中使用,不像 Add-Migration 和 Update-Database 命令,只在特定的 Visual Studio 窗口中工作。


创建种子数据

为了填充数据库并提供一些示例数据,我在 Models 文件夹中添加了一个名为 SeedData.cs 的类文件,并定义了清单8-20所示的类。

清单 8-20:Models 文件夹下的 SeedData.cs 文件的内容

using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
namespace SportsStore.Models
{
    public static class SeedData
    {
        public static void EnsurePopulated(IApplicationBuilder app)
        {
            ApplicationDbContext context = app.ApplicationServices
            .GetRequiredService<ApplicationDbContext>();
            context.Database.Migrate();
            if (!context.Products.Any())
            {
                context.Products.AddRange(
                    new Product
                    {
                        Name = "Kayak",
                        Description = "A boat for one person",
                        Category = "Watersports",
                        Price = 275
                    },
                    new Product
                    {
                        Name = "Lifejacket",
                        Description = "Protective and fashionable",
                        Category = "Watersports",
                        Price = 48.95m
                    },
                    new Product
                    {
                        Name = "Soccer Ball",
                        Description = "FIFA-approved size and weight",
                        Category = "Soccer",
                        Price = 19.50m
                    },
                    new Product
                    {
                        Name = "Corner Flags",
                        Description = "Give your playing field a professional touch",
                        Category = "Soccer",
                        Price = 34.95m
                    },
                    new Product
                    {
                        Name = "Stadium",
                        Description = "Flat-packed 35,000-seat stadium",
                        Category = "Soccer",
                        Price = 79500
                    },
                    new Product
                    {
                        Name = "Thinking Cap",
                        Description = "Improve brain efficiency by 75%",
                        Category = "Chess",
                        Price = 16
                    },
                    new Product
                    {
                        Name = "Unsteady Chair",
                        Description = "Secretly give your opponent a disadvantage",
                        Category = "Chess",
                        Price = 29.95m
                    },
                    new Product
                    {
                        Name = "Human Chess Board",
                        Description = "A fun game for the family",
                        Category = "Chess",
                        Price = 75
                    },
                    new Product
                    {
                        Name = "Bling-Bling King",
                        Description = "Gold-plated, diamond-studded King",
                        Category = "Chess",
                        Price = 1200
                    }
                );
                context.SaveChanges();
            }
        }
    }
}

EnsurePopulated静态方法接受一个IApplicationBuilder参数,它是Startup类的Configure方法中用来注册中间件组件以处理 HTTP 请求的接口,我将在这里确保数据库中有内容。

EnsurePopulated方法通过IApplicationBuilder接口获得一个ApplicationDbContext对象,并调用Database.Migrate方法以确保已应用了迁移,这意味着数据库将被创建和准备,以便它能够存储Product对象。接下来,检查数据库中Product对象的数量。如果数据库中没有对象,则使用AddRange方法将Product对象集合填充数据库,然后使用SaveChanges方法将其写入数据库。

最后的更改是在应用程序启动时为数据库添加种子,这是通过从Startup类中添加对EnsurePopulated方法的调用来完成的,如清单8-21所示。

清单 8-21:SportsStore 文件夹下的 Startup.cs 文件,给数据库添加种子

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) =>
            Configuration = configuration;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration["Data:SportStoreProducts:ConnectionString"]));
            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
                routes.MapRoute(
                name: "default",
                template: "{controller=Product}/{action=List}/{id?}");
            });
            SeedData.EnsurePopulated(app);
        }
    }
}

启动应用程序,数据库将被创建并添加种子,用于给应用程序提供其数据。(请耐心等待,创建数据库可能需要一段时间)。

当浏览器请求应用程序的默认 URL 时,应用程序配置告诉 MVC 它需要创建一个 Product 控制器来处理请求。创建一个新的 Product 控制器意味着调用ProductController构造函数,这需要一个实现IProductRepository接口的对象,而新的配置告诉 MVC 应该创建并使用EFProductRepository对象。EFProductRepository对象访问 Entity Framework Core 功能,从 SQL 服务器加载数据并将其转换为Product对象。所有这些都隐藏在ProductController类中,后者只接收一个实现IProductRepository接口的对象并处理它提供的数据。结果是浏览器窗口显示数据库中的示例数据,如图8-8所示。

图8-8 使用数据库存储库

这种让 Entity Framework Core 将 SQL 服务器数据库表示为一系列模型对象的方法简单易用,使得我可以继续聚焦于 ASP.NET Core MVC。我跳过了 EF Core 操作的很多细节,以及大量可用的配置选项。我非常喜欢 Entity Framework Core,我建议您花一些时间详细了解它。一个很好的起点是微软 Entity Framework Core 站点(http://ef.readthedocs.io),或者我即将出版的关于 Entity Framework Core 的书。

添加分页

您可以从图8-8中看到 List.cshtml 视图在单个页面上显示数据库中的产品。在本节中,我将添加对分页的支持,以便视图在页面上显示较少的产品,用户可以在页面间移动以查看整个目录。为此,我将向 Product 控制器中的List方法添加一个参数,如清单8-22所示。

清单 8-22:Controllers 文件夹下的 ProductController.cs 文件,添加分页

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public int PageSize = 4;
        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(int productPage = 1)
            => View(repository.Products
                .OrderBy(p => p.ProductID)
                .Skip((productPage - 1) * PageSize)
                .Take(PageSize));
    }
}

PageSize字段指定我希望每页有四个产品。我向List方法添加了一个可选参数,这意味着如果我调用没有参数的方法(List()),我的调用将被视为提供了参数定义(List(1))中指定的值。其结果是,当 MVC 调用时未带参数,action 方法将显示产品的第一页。在 action 方法的主体中,我获取Product对象,按主键对它们进行排序,跳过当前页面之前的产品,并获取由PageSize字段指定的产品数量。


单元测试:分页

我可以通过创建模拟存储库,将其注入ProductController类的构造函数,然后调用List方法来请求特定的页面,从而对分页功能进行单元测试。然后,我可以比较我得到的Product对象和我预期的模拟测试中的测试数据。有关如何设置单元测试的详细信息,请参阅第7章。下面是我为此目的创建的单元测试,它位于一个名为 ProductControllerTests.cs 的类文件中,我将它添加到了 SportsStore.Tests 项目中:

using System.Collections.Generic;
using System.Linq;
using Moq;
using SportsStore.Controllers;
using SportsStore.Models;
using Xunit;

namespace SportsStore.Tests
{
    public class ProductControllerTests
    {
        [Fact]
        public void Can_Paginate()
        {
            // Arrange
            Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns((new Product[] {
                new Product {ProductID = 1, Name = "P1"},
                new Product {ProductID = 2, Name = "P2"},
                new Product {ProductID = 3, Name = "P3"},
                new Product {ProductID = 4, Name = "P4"},
                new Product {ProductID = 5, Name = "P5"}
            }).AsQueryable<Product>());
            ProductController controller = new ProductController(mock.Object);
            controller.PageSize = 3;
            // Act
            IEnumerable<Product> result =
                controller.List(2).ViewData.Model as IEnumerable<Product>;
            // Assert
            Product[] prodArray = result.ToArray();
            Assert.True(prodArray.Length == 2);
            Assert.Equal("P4", prodArray[0].Name);
            Assert.Equal("P5", prodArray[1].Name);
        }
    }
}

译者注:按原书操作会报错,错误地点是:controller.List(2).ViewData.Model里的ViewData这块。要解决此问题,需要在测试项目中使用 NuGet 管理工具将【Microsoft.AspNetCore.App(2.1.1)】(参考 SportsStore 项目版本号)程序包引入。

获取 action 方法返回的数据有点尴尬。结果是一个ViewResult对象,我必须将它的ViewData.Model属性的值转换为预期的数据类型。我在第17章中解释了通过 action 方法可以返回的不同结果类型以及如何使用它们。


显示页面链接

如果运行应用程序,您将看到页面上现在显示了四个项。如果要查看另一个页面,可以将查询字符串参数追加到 URL 的末尾,如下所示:

http://localhost:5000/?productPage=2

您需要更改 URL 的端口部分,以匹配分配给项目的任何端口。使用这些查询字符串,您可以在产品目录中导航。

客户没有办法知道这些查询字符串参数的存在,即使知道,他们也不希望以这种方式导航。相反,我需要在每个产品列表的底部呈现一些页面链接,这样客户就可以在页面之间导航。为此,我将创建一个标签助手,为我所需的链接生成 HTML 标记。

添加视图模型

为支持标签助手,我将向视图传递有关可用页面数、当前页和存储库中产品总数的信息。最简单的方法是创建一个视图模型类,它专门用于在控制器和视图之间传递数据。我在 SportsStore 项目中创建了一个 Models/ ViewModels 文件夹,并在其中添加了一个名为 PagingInfo.cs 的类文件,如清单8-23所示。

清单 8-23:Models/ViewModels 文件夹下的 PagingInfo.cs 文件的内容

using System;

namespace SportsStore.Models.ViewModels
{
    public class PagingInfo
    {
        public int TotalItems { get; set; }
        public int ItemsPerPage { get; set; }
        public int CurrentPage { get; set; }
        public int TotalPages =>
        (int)Math.Ceiling((decimal)TotalItems / ItemsPerPage);
    }
}

添加标签助手类

既然有了一个视图模型,就可以创建一个标签助手类了。我在 SportsStore 项目中创建了 Infrastructure 文件夹,并在其中添加了一个名为 PageLinkTagHelper.cs 的类文件,用于定义清单8-24所示的类。标签助手是 ASP.NET Core MVC 的一个重要部分,我在第23、24和25章中解释了它们的工作原理和如何创建它们。

提示:Infrastructure 文件夹是我放置类的地方,这些类为应用程序提供管道,但与应用程序的域无关。

清单 8-24:Infrastructure 文件夹下的 PageLinkTagHelper.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using SportsStore.Models.ViewModels;
namespace SportsStore.Infrastructure
{
    [HtmlTargetElement("div", Attributes = "page-model")]
    public class PageLinkTagHelper : TagHelper
    {
        private IUrlHelperFactory urlHelperFactory;

        public PageLinkTagHelper(IUrlHelperFactory helperFactory)
        {
            urlHelperFactory = helperFactory;
        }

        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public PagingInfo PageModel { get; set; }
        public string PageAction { get; set; }

        public override void Process(TagHelperContext context,
        TagHelperOutput output)
        {
            IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
            TagBuilder result = new TagBuilder("div");
            for (int i = 1; i <= PageModel.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                tag.Attributes["href"] = urlHelper.Action(PageAction,
                new { productPage = i });
                tag.InnerHtml.Append(i.ToString());
                result.InnerHtml.AppendHtml(tag);
            }
            output.Content.AppendHtml(result.InnerHtml);
        }
    }
}

这个标签助手将对应于产品页的元素填充进div元素。我现在不打算详细讨论标签助手;只要知道它们是将 C# 逻辑引入视图的最有效的方法之一,就足够了。标签助手的代码看起来很痛苦,因为 C# 和 HTML 不容易混合。但是使用标签助手比在视图中包含 C# 代码块更可取,因为标签助手可以很容易地进行单元测试。

大多数 MVC 组件,如控制器和视图,都是自动发现的,但是标签助手必须注册。在清单8-25中,我在 Views 文件夹中的 _ViewImports.cshtml 文件中添加了一个语句,告诉 MVC 在 SportsStore.Infrastructure 命名空间中查找标签助手类。我还添加了一个@using表达式,这样我就可以引用视图中的视图模型类,而不必用命名空间限定它们的名称。

清单 8-25:Views/Shared 文件夹下的 _ViewImports.cshtml 文件,注册标签助手

@using SportsStore.Models
@using SportsStore.Models.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper SportsStore.Infrastructure.*, SportsStore

单元测试:创建页面链接

为了测试页面标签助手类,我用测试数据调用Process方法,并提供一个TagHelperOutput对象以查看生成的 HTML,如下所示,这是我在 SportsStore.Tests 项目中的一个新的 PageLinkTagHelperTests.cs 文件中定义的:

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Moq;
using SportsStore.Infrastructure;
using SportsStore.Models.ViewModels;
using Xunit;

namespace SportsStore.Tests
{
    public class PageLinkTagHelperTests
    {
        [Fact]
        public void Can_Generate_Page_Links()
        {
            // Arrange
            var urlHelper = new Mock<IUrlHelper>();
            urlHelper.SetupSequence(x => x.Action(It.IsAny<UrlActionContext>()))
                .Returns("Test/Page1")
                .Returns("Test/Page2")
                .Returns("Test/Page3");
            var urlHelperFactory = new Mock<IUrlHelperFactory>();
            urlHelperFactory.Setup(f =>
                f.GetUrlHelper(It.IsAny<ActionContext>()))
                    .Returns(urlHelper.Object);
            PageLinkTagHelper helper =
                new PageLinkTagHelper(urlHelperFactory.Object)
            {
                PageModel = new PagingInfo
                {
                    CurrentPage = 2,
                    TotalItems = 28,
                    ItemsPerPage = 10
                },
                PageAction = "Test"
            };
            TagHelperContext ctx = new TagHelperContext(
                new TagHelperAttributeList(),
                new Dictionary<object, object>(), "");
            var content = new Mock<TagHelperContent>();
            TagHelperOutput output = new TagHelperOutput("div",
                new TagHelperAttributeList(),
                (cache, encoder) => Task.FromResult(content.Object));
            // Act
            helper.Process(ctx, output);
            // Assert
            Assert.Equal(@"<a href=""Test/Page1"">1</a>"
                + @"<a href=""Test/Page2"">2</a>"
                + @"<a href=""Test/Page3"">3</a>",
                output.Content.GetContent());
        }
    }
}

此测试的复杂性在于创建和使用标签助手所需的对象。标签助手使用IUrlHelperFactory对象生成针对应用程序不同部分的 URL,我已经使用 Moq 创建了该接口的实现,以及提供测试数据的相关的IUrlHelper接口。

测试的核心部分通过使用包含双引号的文字字符串值来验证标签助手的输出。只要字符串以@作为前缀,并且使用两组双引号(" ")代替一组双引号,C# 就完全能够处理这些字符串。必须记住不要将文本字符串拆分为单独的行,除非与之相比较的字符串也同样断开。例如,我在测试方法中使用的文字已经包装在几行上,因为打印页面的宽度很窄。我没有添加换行符;如果添加了,测试就会失败。


添加视图模型数据

我还没有完全准备好使用标签助手,因为我还没有向视图提供PagingInfo视图模型类的实例。我可以使用 view bag 特性来完成这个任务,但是我宁愿将从控制器发送到视图的所有数据打包到一个视图模型类中。为此,我将一个名为 ProductsListViewModel.cs 的类文件添加到 SportsStore 项目的 Models/ViewModels 文件夹中。清单8-26显示了新文件的内容。

清单 8-26:Models/ViewModels 文件夹下的 ProductsListViewModel.cs 文件的内容

using System.Collections.Generic;
using SportsStore.Models;
namespace SportsStore.Models.ViewModels
{
    public class ProductsListViewModel
    {
        public IEnumerable<Product> Products { get; set; }
        public PagingInfo PagingInfo { get; set; }
    }
}

现在可以更新ProductController类中的List action 方法,以使用ProductsListViewModel类为视图提供要在页面上显示的产品的详细信息和分页的详细信息,如清单8-27所示。

清单 8-27 Controllers 文件夹下的 ProductController.cs 文件,更新List方法

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;
using SportsStore.Models.ViewModels;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public int PageSize = 4;
        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(int productPage = 1)
            => View(new ProductsListViewModel
            {
                Products = repository.Products
                    .OrderBy(p => p.ProductID)
                    .Skip((productPage - 1) * PageSize)
                    .Take(PageSize),
                PagingInfo = new PagingInfo
                {
                    CurrentPage = productPage,
                    ItemsPerPage = PageSize,
                    TotalItems = repository.Products.Count()
                }
            });
    }
}

这些更改将ProductsListViewModel对象作为模型数据传递给视图。


单元测试:页面模型视图数据

我需要确保控制器向视图发送正确的分页数据。下面是我添加到测试项目中ProductControllerTests类的单元测试代码:

[Fact]
public void Can_Send_Pagination_View_Model()
{
    // Arrange
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
        new Product {ProductID = 4, Name = "P4"},
        new Product {ProductID = 5, Name = "P5"}
    }).AsQueryable<Product>());
    // Arrange
    ProductController controller =
    new ProductController(mock.Object) { PageSize = 3 };
    // Act
    ProductsListViewModel result =
    controller.List(2).ViewData.Model as ProductsListViewModel;
    // Assert
    PagingInfo pageInfo = result.PagingInfo;
    Assert.Equal(2, pageInfo.CurrentPage);
    Assert.Equal(3, pageInfo.ItemsPerPage);
    Assert.Equal(5, pageInfo.TotalItems);
    Assert.Equal(2, pageInfo.TotalPages);
}

我还需要修改更早的分页单元测试,该测试包含在Can_Paginate方法中。它依赖于List action 方法返回的一个ViewResult,其Model属性是Product对象的序列,但我已经将数据包装在另一种视图模型类型中。下面是修改后的测试:

[Fact]
public void Can_Paginate()
{
    // Arrange
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
        new Product {ProductID = 4, Name = "P4"},
        new Product {ProductID = 5, Name = "P5"}
    }).AsQueryable<Product>());
    ProductController controller = new ProductController(mock.Object);
    controller.PageSize = 3;
    // Act
    ProductsListViewModel result =
        controller.List(2).ViewData.Model as ProductsListViewModel;
    // Assert
    Product[] prodArray = result.Products.ToArray();
    Assert.True(prodArray.Length == 2);
    Assert.Equal("P4", prodArray[0].Name);
    Assert.Equal("P5", prodArray[1].Name);
}

考虑到这两种测试方法之间的重复程度,我通常会创建一个通用的设置方法。但是,由于我是在单独的侧边栏中交付单元测试,所以我将保持每件事都是分开的,这样您就可以单独看到每个测试了。


视图当前需要一系列Product对象,因此我需要更新 List.cshtml 文件,如清单8-28所示,以处理新的视图模型类型。

清单 8-28:Views/Product 文件夹下的 List.cshtml 文件的更新

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

我已经更改了@model指令,告诉 Razor 我现在使用的是不同的数据类型。我更新了foreach循环,以便数据源是模型数据的Products属性。

显示页面链接

我已经准备好了将页面链接添加到List视图中。我创建了包含分页信息的视图模型,更新了控制器,使其将此信息传递给视图,并更改了@model指令以匹配新的模型视图类型。剩下的就是添加一个 HTML 元素,标签助手将处理这个元素来创建页面链接,如清单8-29所示。

清单 8-29:Views/Product 文件夹下的 List.cshtml 文件,添加分页链接

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

<div page-model="@Model.PagingInfo" page-action="List"></div>

如果您运行应用程序,将看到新的页面链接,如图8-9所示。样式仍然是基本的,我将在本章后面对其进行修复。目前最重要的是链接将用户从目录中的一个页面带到另一个页面,并允许对销售的产品进行搜索。当 Razor 在 div 元素上找到page-model属性时,它会要求PageLinkTagHelper类转换元素,该元素生成如图所示的链接集。

图8-9 显示页面导航链接


为何不直接使用 GridView ?

如果您以前使用过ASP.NET,可能会认为这是为了一个平淡无奇的结果而做的大量工作。我花了大量的时间来获得一个简单的分页产品列表。如果我使用的是 Web Forms,可以使用 ASP.NET Web Forms 的 GridView 或 ListView 控件来完成相同的任务,开箱即用,将它们直接连接到 Products 数据库表。

我在本章中完成的工作看起来可能不太像,但它与将控件拖到设计界面上有着很大的不同。首先,我正在构建一个具有良好和可维护体系结构的应用程序,该体系结构涉及适当的关注点分离。与 ListView 控件的最简单使用不同,我没有直接耦合 UI 和数据库,这是一种提供快速结果的方法,但随着时间的推移,会造成痛苦和烦恼。第二,我一直在创建单元测试,这些测试允许我以一种自然的方式验证应用程序的行为,这在复杂的 Web Forms 控件中几乎是不可能的。最后,请记住,我在本章已经为创建构建应用程序的底层基础结构的过程提供了很多。例如,我只需要定义和实现存储库一次,现在我已经能够快速、轻松地构建和测试新功能,下面的章节将演示这一点。

当然,所有这些都不会影响 Web Forms 能够提供的即时结果,但正如我在第3章中所解释的那样,即时性带来的代价在大型和复杂的项目中可能是昂贵和痛苦的。


改进 URLs

页面链接工作正常,但它们仍然使用查询字符串将页面信息传递给服务器,如下所示:

http://localhost/?productPage=2

我可以通过使用一个遵循 组合式URL(composable URLs) 模式的方案来创建更有吸引力的 URLs。一个组合式URL 对用户来说是有意义的,就像这个:

http://localhost/Page2

MVC 使得在应用程序中更改 URL 架构变得容易,因为它使用了 ASP.NET Core 路由功能,该功能负责处理 URL 以找出它们所针对的是应用程序的哪个部分。我所需要做的就是在Startup类的Configure方法中注册 MVC 中间件时添加一个新的路由,如清单8-30所示。

清单 8-30:SportsStore 文件夹下的 Startup.cs 文件,添加一个新路由

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) =>
            Configuration = configuration;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration["Data:SportStoreProducts:ConnectionString"]));
            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
                routes.MapRoute(
                    name: "pagination",
                    template: "Products/Page{productPage}",
                    defaults: new { Controller = "Product", action = "List" });

                routes.MapRoute(
                    name: "default",
                    template: "{controller=Product}/{action=List}/{id?}");
            });
            SeedData.EnsurePopulated(app);
        }
    }
}

在已存在于方法内的默认路由之前添加新路由是很重要的。正如您将在第15章中了解的那样,路由系统按照它们列出的顺序处理路由,我需要新路由优先于现有路由。

这是更改产品分页的 URL 方案所需的唯一更改。MVC 和路由功能紧密结合,因此,应用程序自动反映应用程序使用 URL 中的更改,包括标签助手生成的内容,比如我用来生成页面导航链接的工具。如果您现在不理解路由,不要担心。我在第15章和第16章中对此做了详细的解释。

如果运行应用程序并单击分页链接,您将看到新的 URL 方案正在实施,如图8-10所示。

图8-10 在浏览器中显示的新 URL 架构

给内容添加样式

我已经构建了大量的基础设施,应用程序的基本功能也开始结合在一起,但对于外观并没有给予太多注意力。尽管这本书不是关于设计或 CSS 的,但是运动商店的应用程序设计是如此的简单,以至于削弱了它的技术优势。在本节中,我会纠正一部分。我将实现一个典型的带有标题的两列布局,如图8-11所示。

图8-11 运动商店应用程序的设计目标

安装 Bootstrap 包

我将使用 Bootstrap 包来提供应用程序的 CSS 样式。在【解决方案资源管理器】中的 SportsStore 项目上单击鼠标右键,在弹出的菜单中选择【添加】➤【添加客户端库】打开【添加客户端库】窗口。在【库】栏输入“twitter-bootstrap”。出现【twitter-bootstrap@4.1.3】后单击【安装】按钮,将 Bootstrap 加入到项目中。

将 Bootstrap 样式应用于布局

在第5章,我解释了 Razor 布局是如何工作的,如何使用它们,以及如何整合布局。我在本章开头添加的 view start 文件指定应该使用名为 _layout.cshtml 的文件作为默认布局,这是应用初始 Bootstrap 样式的地方,如清单8-32所示。

清单 8-32:Views/Shared 文件夹下的 _Layout.cshtml 文件,应用 Bootstrap CSS

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet"
          asp-href-include="/lib/twitter-bootstrap/**/*.min.css"
          asp-href-exclude="**/*-reboot*,**/*-grid*" />
    <title>SportsStore</title>
</head>
<body>
    <div class="navbar navbar-inverse bg-inverse" role="navigation">
        <a class="navbar-brand" href="#">SPORTS STORE</a>
    </div>
    <div class="row m-1 p-1">
        <div id="categories" class="col-3">
            Put something useful here later
        </div>
        <div class="col-9">
            @RenderBody()
        </div>
    </div>
</body>
</html>

清单中的link元素使用了asp-href-include以及asp-href-exclude属性,这是一个表现内置标签助手类的示例。在这种情况下,标签助手查看属性的值,并为所有匹配由include属性指定的路径和由exclude属性指定的路径的文件生成链接元素。属性使用的路径可以包含通配符,这使得这是一个有用的特性,以确保您可以在不中断应用程序的情况下从 wwwroot 文件夹结构中添加和删除文件。但是,正如我在第25章中所解释的,需要谨慎以确保指定的路径只选择您所期望的文件。

将 Bootstrap CSS 样式表添加到布局中意味着我可以使用它在任何依赖于布局的视图中定义的样式。在清单8-33中,您可以看到我应用到 List.cshtml 文件的样式。

清单 8-33:Views/Product 文件夹下的 List.cshtml 文件,给内容添加样式

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div class="card card-outline-primary m-1 p-1">
        <div class="bg-faded p-1">
            <h4>
                @p.Name
                <span class="badge badge-pill badge-primary" style="float:right">
                    <small>@p.Price.ToString("c")</small>
                </span>
            </h4>
        </div>
        <div class="card-text p-1">@p.Description</div>
    </div>
}

<div page-model="@Model.PagingInfo" page-action="List" page-classes-enabled="true"
     page-class="btn" page-class-normal="btn-secondary"
     page-class-selected="btn-primary" class="btn-group pull-right m-1">
</div>

我需要样式化由PageLinkTagHelper类生成的按钮,但我不希望将 Bootstrap 类硬连接到 C# 代码中,因为它使得在应用程序中的其他地方重用标签助手或改变按钮的外观变得更加困难。相反,我在div元素上定义了自定义属性,这些属性指定了我需要的类,它们对应于我添加到标签助手类中的属性,然后被用来对生成的a元素进行样式化,如清单8-34所示。

清单 8-34:PageLinkTagHelper.cs 文件,添加类以生成元素

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using SportsStore.Models.ViewModels;
namespace SportsStore.Infrastructure
{
    [HtmlTargetElement("div", Attributes = "page-model")]
    public class PageLinkTagHelper : TagHelper
    {
        private IUrlHelperFactory urlHelperFactory;

        public PageLinkTagHelper(IUrlHelperFactory helperFactory)
        {
            urlHelperFactory = helperFactory;
        }

        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public PagingInfo PageModel { get; set; }
        public string PageAction { get; set; }
        public bool PageClassesEnabled { get; set; } = false;
        public string PageClass { get; set; }
        public string PageClassNormal { get; set; }
        public string PageClassSelected { get; set; }

        public override void Process(TagHelperContext context,
        TagHelperOutput output)
        {
            IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
            TagBuilder result = new TagBuilder("div");
            for (int i = 1; i <= PageModel.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                tag.Attributes["href"] = urlHelper.Action(PageAction,
                    new { productPage = i });
                if (PageClassesEnabled)
                {
                    tag.AddCssClass(PageClass);
                    tag.AddCssClass(i == PageModel.CurrentPage
                    ? PageClassSelected : PageClassNormal);
                }
                tag.InnerHtml.Append(i.ToString());
                result.InnerHtml.AppendHtml(tag);
            }
            output.Content.AppendHtml(result.InnerHtml);
        }
    }
}

特性值自动用于设置标签助手属性值,并考虑 HTML 属性名称格式(page-class-normal)和C#属性名称格式(PageClassNormal)之间的映射。这允许标签助手根据 HTML 元素的属性进行不同的响应,从而创建一种更灵活的方法以在 MVC 应用程序中生成内容。

如果运行应用程序,您将看到应用程序的外观已经得到了改进 —— 至少有一点 —— 如图8-12所示。

图8-12 设计增强的运动商店应用程序

创建分布视图

作为本章的一大亮点,我将重构应用程序以简化 List.cshtml 视图,我将创建一个 分部视图(partial view),它是一个内容片段,您可以将其嵌入到另一个视图中,就像模板一样。我在第21章中详细描述了分部视图,当您需要在应用程序的不同位置显示相同的内容时,它们有助于减少重复。与其将相同的 Razor 标记复制并粘贴到多个视图中,不如在分部视图中定义一次。为了创建分部视图,我在 Views/Shared 文件夹中添加了一个名为 ProductSummary.cshtml 的 Razor 视图文件,并添加了清单8-35中所示的标记。

清单 8-35:Views/Shared 文件夹下的 ProductSummary.cshtml 文件的内容

@model Product

<div class="card card-outline-primary m-1 p-1">
    <div class="bg-faded p-1">
        <h4>
            @Model.Name
            <span class="badge badge-pill badge-primary" style="float:right">
                <small>@Model.Price.ToString("c")</small>
            </span>
        </h4>
    </div>
    <div class="card-text p-1">@Model.Description</div>
</div>

现在,我需要更新 Views/Products 文件夹中的 List.cshtml 文件,以便它使用分部视图,如清单8-36所示。

清单 8-36:Lists.cshtml 文件,使用分部视图

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    @Html.Partial("ProductSummary", p)
}

<div page-model="@Model.PagingInfo" page-action="List" page-classes-enabled="true"
     page-class="btn" page-class-normal="btn-secondary"
     page-class-selected="btn-primary" class="btn-group pull-right m-1">
</div>

译者注:此处按原文代码输入后,Visual Studio 会报一个警告。这里使用的是分部视图呈现的同步方法,而微软文档中已经明示,由于可能会出现死锁情况,未来版本将不包含同步方法。建议在 ASP.NET Core 2.1 或更高版本中使用异步方法。具体方法是将
@Html.Partial("ProductSummary", p)
更改为:
@await Html.PartialAsync("ProductSummary", p)
即可不再出现警告。

我使用了以前在 List.cshtml 视图中的foreach循环中的标记,并将其移动到新的分部视图中。我使用Html.Partial助手方法调用分部视图,并以视图名称和视图模型对象作为参数。切换到这样的分部视图是很好的做法,因为它允许将相同的标记插入到任何需要显示产品摘要的视图中。如图8-13所示,添加分部视图不会改变应用程序的外观;它只会改变 Razor 查找用于生成发送到浏览器的响应内容的位置。

图8-13 应用分部视图

总结

本章我构建了体育商店应用程序的核心基础设施。此时它还未具备向客户演示许多功能,但在幕后,已经有一个由 SQL Server 和 Entity Framework Core 支持的产品存储库的域模型。有一个控制器ProductController,它可以生成产品的分页列表,并且我已经建立了一个干净友好的 URL 架构。

如果这一章只是对一些小事作了很多安排,那么下一章将会平衡。既然基本架构已经到位,我们就可以向前迈进,增加所有面向客户的功能:按类别导航、购物车和开始结账流程。

;

© 2018 - IOT小分队文章发布系统 v0.3